iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Modern Web

前端來點 AWS 技能樹!系列 第 25 篇

Day25 - push 到 main,讓 GitHub Actions 自動部署到 EC2

  • 分享至 

  • xImage
  •  

昨天讓 GitHub Actions 換得到 AWS 的身分了,但 gha-deploy-role 還沒有任何權限,什麼事都不能做。
今天就來寫 deploy.yml:只要 push 到 main,GitHub Actions 就會自動 build Image、推上 ECR,再通知 EC2 換上新版本。過程中用到哪些權限,就幫 Role 加哪些ㄅ!

今天要交給 workflow 的事

Day23 整理過手動時進版的步驟,今天就把它們一個一個交給 workflow:

步驟 手動的時候 交給 workflow 之後
build Image 在 Mac 上 build,要加 --platform linux/amd64 在 GitHub 的 Runner 上 build,它本來就是 x86_64,不用加
push 到 ECR 用自己的 IAM 身分登入 ECR 用 Day24 設定的 OIDC,換到 gha-deploy-role 的身分
pull Image、換 container 開 Session Manager,一行一行打指令 用 SSM 的 Run Command,把同樣的指令送到 EC2 上執行
確認網站正常 自己開瀏覽器或 curl 最後自動 curl 一次

整個 workflow 跑起來的順序是這樣:

  1. 先 push 到 main,觸發 workflow。
  2. Runner 用 OIDC 跟 AWS 換到 gha-deploy-role 的短期權限。
  3. build Image,用這次 commit 的 SHA 當 tag,push 到 ECR 中。
  4. 再透過 SSM 通知 EC2:拉下新的 Image、刪掉舊的 container、用新的 Image 再跑一個。
  5. curl 網址,確認有回應。

Image 的 tag 改用 commit SHA

Day13 推上 ECR 的 Image,tag 是用 v1。那之後每次部署,都推成 v1 可以嗎?
我們在 Day13 建的 ECR repo 是用 Mutable,所以同一個 tag 的 image 可以重複推。而再推一次 v1 時,ECR 會把 v1 這個名字移到新的 Image 上,舊的那個就變成沒有 tag 的 Image(untagged)。
但這樣會出現兩個兩個麻煩:

  • 想退回上一版時,找不到它的名字
  • 看到 v1 也不知道裡面是哪一版的程式碼。

所以 workflow 我們改用 commit 的 SHA 當 tag。SHA 是 git 替每個 commit 算出來的一串編號,每個 commit 都不一樣,在 GitHub 上也查得到那次 commit 改了什麼。之後在 EC2 上看到 my-app:<SHA>,就知道網站跑的是哪一版。

幫 Role 加上部署需要的權限

workflow 要做的事,對應到 AWS 需要這幾個權限:

要做的事 需要的權限
登入 ECR ecr:GetAuthorizationToken
push Image 到 my-app 6 個上傳用的權限,只限 my-app 這個 repo
通知 EC2 執行指令 ssm:SendCommand,只限這台 EC2 和 AWS-RunShellScript
查詢指令的執行結果 ssm:GetCommandInvocation

先到 EC2 的 Console 點進你建的 Instance,複製 Instance ID(i- 開頭的那串),等一下會用到。接著到 IAM 的 Console:

  1. 左邊選單點 Roles(角色),點進 Day24建的 gha-deploy-role。
  2. 在 Permissions(許可) Tab 按 Add permissions(新增許可),選 Create inline policy(建立內嵌政策)。
  3. 切到 JSON,把內容換成下面這段,再把 123456789012 換成自己的帳號 ID、i-0123456789abcdef0 換成剛剛複製的 Instance ID。
  4. 按 Next,Policy name 填 deploy-permissions,按 Create policy(建立政策)。
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "LoginToECR",
            "Effect": "Allow",
            "Action": "ecr:GetAuthorizationToken",
            "Resource": "*"
        },
        {
            "Sid": "PushToMyAppRepo",
            "Effect": "Allow",
            "Action": [
                "ecr:BatchCheckLayerAvailability",
                "ecr:InitiateLayerUpload",
                "ecr:UploadLayerPart",
                "ecr:CompleteLayerUpload",
                "ecr:PutImage",
                "ecr:BatchGetImage"
            ],
            "Resource": "arn:aws:ecr:ap-east-2:123456789012:repository/my-app"
        },
        {
            "Sid": "SendDeployCommand",
            "Effect": "Allow",
            "Action": "ssm:SendCommand",
            "Resource": [
                "arn:aws:ec2:ap-east-2:123456789012:instance/i-0123456789abcdef0",
                "arn:aws:ssm:ap-east-2::document/AWS-RunShellScript"
            ]
        },
        {
            "Sid": "ReadCommandResult",
            "Effect": "Allow",
            "Action": "ssm:GetCommandInvocation",
            "Resource": "*"
        }
    ]
}

逐段來看:

  • LoginToECR:ECR 的登入是以整個 registry(也就是整個帳號)為單位,不是針對某一個 repo,所以這個權限只能寫 *。
  • PushToMyAppRepo:push Image 時,Docker 會先問 ECR 哪些層已經有了,再把缺少的層分段上傳,最後寫入這個 Image 的清單(manifest);BatchGetImage 是讀取清單用的,AWS 文件列出的 push 權限也包含它。這 6 個權限只對 my-app 這個 repo 有效,就算 workflow 被改壞,也推不到別的 repo。
  • SendDeployCommand:只能對這台 EC2 下指令,而且只能用 AWS-RunShellScript 這份文件(Document),也就是「執行 shell 指令」。這份文件是 AWS 提供的,所以 ARN 裡沒有帳號 ID。
  • ReadCommandResult:查詢指令跑完了沒、印出了什麼。這個權限沒辦法限定資源,只能寫 *,但它只能讀結果,不能下指令。

為什麼不直接掛 AWS 提供的現成政策,例如 AmazonEC2ContainerRegistryPowerUser?因為這類政策的範圍是整個帳號:所有 ECR repo 都推得上去。自己寫雖然麻煩一點,但可以把權限範圍限制在這個 repo 和這台 EC2。不過 AWS-RunShellScript 仍能在這台主機執行 root 指令,所以能修改 workflow 的權限也要顧好。

寫 deploy.yml

在專案裡新增 .github/workflows/deploy.yml,env 底下的四個值換成自己的:

name: Deploy to EC2

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

concurrency:
  group: deploy
  cancel-in-progress: false

env:
  AWS_REGION: ap-east-2
  ECR_REPOSITORY: my-app
  EC2_INSTANCE_ID: i-0123456789abcdef0
  SITE_URL: https://example.com

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push image
        env:
          IMAGE: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
        run: |
          docker build -t "$IMAGE" .
          docker push "$IMAGE"

      - name: Deploy to EC2
        env:
          REGISTRY: ${{ steps.ecr.outputs.registry }}
          IMAGE: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
        run: |
          cat > deploy-commands.json <<EOF
          {
            "commands": [
              "set -e",
              "aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $REGISTRY",
              "docker pull $IMAGE",
              "docker rm -f my-app || true",
              "docker run -d --name my-app --restart unless-stopped -p 127.0.0.1:3000:3000 $IMAGE",
              "docker image prune -a -f"
            ]
          }
          EOF

          COMMAND_ID=$(aws ssm send-command \
            --instance-ids "$EC2_INSTANCE_ID" \
            --document-name AWS-RunShellScript \
            --comment "Deploy $GITHUB_SHA" \
            --parameters file://deploy-commands.json \
            --query Command.CommandId \
            --output text)
          echo "Command ID: $COMMAND_ID"

          aws ssm wait command-executed --command-id "$COMMAND_ID" --instance-id "$EC2_INSTANCE_ID" || true

          STATUS=$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$EC2_INSTANCE_ID" --query Status --output text)
          echo "--- EC2 output ---"
          aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$EC2_INSTANCE_ID" --query StandardOutputContent --output text
          echo "--- EC2 errors ---"
          aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$EC2_INSTANCE_ID" --query StandardErrorContent --output text
          echo "Status: $STATUS"
          test "$STATUS" = "Success"

      - name: Smoke test
        run: |
          curl --fail --silent --show-error --output /dev/null \
            --retry 5 --retry-delay 3 --retry-all-errors \
            --write-out "%{http_code}\n" "$SITE_URL"

看起來很長,但大部分在 Day24 的 oidc-test.yml 和 Day13 的手動指令都看過了。所以我們大致逐段來看一下:

  • on:push 到 main 時自動執行;workflow_dispatch 則是保留手動執行的按鈕,之後想重新部署同一版時可以用。
  • permissions:id-token: write 是 OIDC 要用的。Day24 的 workflow 不用讀程式碼,所以沒有寫 contents;今天要 checkout 程式碼來 build,所以多了 contents: read。
  • concurrency:同一個 group 同時只會跑一個。如果短時間內 push 兩次的話,第二次會等第一次跑完才開始,不會有兩個部署同時去換 container。
  • env:整個 workflow 共用的設定。Instance ID 和 Domain 都不是密碼,直接寫在檔案裡就好。

接著是每個步驟:

  • Checkout:把 repo 的程式碼抓到 Runner 上,後面才有 Dockerfile 可以 build。
  • Configure AWS credentials:跟 Day24 一樣,用 OIDC 換到 gha-deploy-role 的短期權限。
  • Login to Amazon ECR:AWS 官方的 action,做的事就是 Day13 的 aws ecr get-login-password | docker login。它會把 registry 的位址(123456789012.dkr.ecr.ap-east-2.amazonaws.com)放在 steps.ecr.outputs.registry,後面的步驟拿來組出完整的 Image 名稱。
  • Build and push image:github.sha 就是這次 commit 的 SHA。Runner 是 x86_64,跟 EC2 一樣,所以就不用加 --platform。
  • Deploy to EC2:先把要在 EC2 上執行的指令寫成一個 JSON 檔,再用 aws ssm send-command 送過去。最後用 wait 等它跑完,把 EC2 上印出的內容和結果印出來,結果不是 Success 的話就讓這一步失敗。
  • Smoke test:curl 網站,失敗的話每 3 秒重試一次,最多 5 次。換 container 的那一兩秒,Caddy 會回 502,重試就是為了等新的 container 起來。

送到 EC2 上的那幾行指令,就是我們之前在 Session Manager 裡打過的:

指令 做什麼
set -e 任何一行失敗就停下來,不會繼續往下執行
aws ecr get-login-password ... | docker login ... EC2 用自己的 Role 登入 ECR(Day13 加的拉取權限)
docker pull 先把新的 Image 拉下來,這時網站還是舊版,照常運作
docker rm -f my-app || true 停掉並刪除舊的 container;第一次部署時如果沒有這個 container,也不會因此失敗
docker run ... 用新的 Image 跑 container,參數跟 Day22 一樣:綁 127.0.0.1:3000,讓 Caddy 轉過來
docker image prune -a -f 刪掉沒有 container 在用的 Image,避免舊版本一直堆在 EC2 上

先 pull 再刪舊的 container,是為了讓網站中斷的時間只有「新 container 啟動」那一兩秒;如果 pull 失敗(例如登入過期),set -e 會讓指令停在這裡,舊的 container 還在跑,所以網站不會受影響。
另外,SSM 是用 root 身分執行指令,所以這些 docker 指令都不用加 sudo。
但有三個地方特別注意一下:

  • GitHub Actions 的 run 遇到失敗的指令就會直接結束這一步。wait 在 EC2 執行失敗時也會回傳錯誤,所以後面加上 || true,讓它繼續往下,把 EC2 上的錯誤訊息印出來,最後再用 test 決定這一步成功或失敗。
  • deploy-commands.json 用的是 <<EOF(沒有加引號),$IMAGE 這些變數才會在寫進檔案時換成真正的值。
  • 這裡的 wait 最多只等約 100 秒。如果 EC2 拉 Image 比較久,Actions 可能先顯示失敗,但 EC2 其實還在部署。遇到這種情況,先到 Systems Manager 的 Run Command → Command history,用 logs 裡的 Command ID 查看進度。確認那次指令已經結束,再決定要不要重新部署,避免兩次部署同時進行。

推上 main,讓它自己部署

為了看得出是不是新版,先改一下首頁。打開 app/page.tsx(有 src 資料夾的話是 src/app/page.tsx),在 <main ...> 的下一行加上:

<p>Deployed by GitHub Actions</p>

接著把 deploy.yml 和首頁的修改一起推上 main:

git add .github/workflows/deploy.yml app/page.tsx
git commit -m "ci: push 到 main 自動部署到 EC2"
git push origin main

PS. Day24 的 oidc-test.yml 已經用不到了,想要的話可以一起刪掉。
push 完到 repo 的 Actions Tab,就會看到 Deploy to EC2 正在執行。點進去可以看到每個步驟的 logs,第一次大約兩三分鐘會跑完:

  • Build and push image:最後會看到 push 的結果,包含這個 Image 的 digest(sha256: 開頭)。
  • Deploy to EC2:EC2 output 那段會依序看到 Login Succeeded、pull 的進度、新 container 的 ID,以及 prune 清掉了多少空間,最後是 Status: Success。EC2 errors 那段如果出現 WARNING! Your password will be stored unencrypted,是 docker login 的提醒,不是錯誤。
  • Smoke test:印出 200。

全部綠燈之後,用瀏覽器打開網站,就會看到剛剛加的 Deployed by GitHub Actions 了!
想確認 EC2 上跑的是哪一版,可以用 Session Manager 連進去看:

sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'

https://ithelp.ithome.com.tw/upload/images/20261009/20179793YKROwpAM8q.png
IMAGE 那一欄的結尾,就是剛剛那次 commit 的 SHA,跟 GitHub 上 commit 列表的編號對得起來。如果 Day22 留下的 my-app-old 還在,現在也可以用 sudo docker rm my-app-old 刪掉了。
另外,每次送到 EC2 的指令都會留下紀錄。到 Systems Manager 的 Console,左邊選單點 Run Command,切到 Command history Tab,就能看到每一次部署(Comment 是 Deploy 加上 commit SHA),點進去也看得到 EC2 上的輸出。

想退回上一版怎麼辦?

如果之後改了程式,上線才發現有問題,可以用 git revert 撤銷修改,再 push,讓 workflow 重新部署。下面假設最新一筆 commit 就是要撤銷的程式修改,而且沒有改到 deploy.yml。
今天這筆 commit 同時新增了 deploy.yml,不要直接拿它來測試,否則連自動部署的設定都會一起刪掉。如果只是想拿掉首頁的測試文字,刪掉那行文字,再 commit、push 就好。

git revert HEAD
git push origin main

git revert 不會刪掉歷史,而是新增一個「把那次修改反過來」的 commit。push 之後,workflow 就會照平常的流程,把撤銷後的版本部署上去,程式碼和網站也會保持一致。

我踩過的坑

我自己的 side project 一開始接上自動部署時,前後踩了好幾個坑:

  • workflow 檔要放在被 push 的那個 branch 上:我一開始只把 deploy.yml 放在開發用的 branch,觸發條件寫的卻是 push 到 main。GitHub Actions 讀的是「被 push 的那個 branch 上的 workflow 檔」,main 上沒有這個檔案,所以 merge 進 main 之後什麼事都沒發生。手動執行的按鈕也一樣,只認預設 branch(通常是 main)上的 workflow 檔。
  • 第一次在 GitHub 上 build 才發現問題:deploy.yml 補進 main 之後,馬上連續紅燈兩次。原因是 main 落後開發 branch 太多:第一次是 main 上的 Dockerfile 還是舊版,npm ci 時出現 sh: 1: husky: not found;第二次是程式碼裡還有地方 import 一個已經被刪掉的東西,build 時被 ESLint 擋下來。這些問題在我自己的電腦上都沒出現過,後來我另外加了一個在開 PR 時就先 build、跑測試的 workflow,問題在 merge 之前就會先現形。
  • docker image prune -f 清不掉舊版本:我一開始寫的是不加 -a 的 prune -f,它只會刪掉沒有 tag 的 Image,但每個舊版本都有自己的 SHA tag,所以一個都沒刪到。前陣子上去看,EC2 上有 6 個版本的 Image,只有 1 個在用,docker system df 顯示有 638MB(85%)可以回收。加上 -a 才會連沒在用的舊版本一起刪掉,要退回舊版時再從 ECR 拉回來就好。

到這裡,只要 push 到 main,新版本就會自動上線了!EC2 這條路也走到尾聲,下一篇來整理網站上線之後還要顧的事:logs 去哪裡看、掛掉時怎麼收到通知、安全設定和費用的檢查清單ㄅ!

資安小提醒:inline policy 和 Actions 的 logs 裡都有 12 碼的帳號 ID(ECR 的位址開頭就是它),deploy.yml 裡也有 Instance ID 和網域,截圖前記得遮,repo 也請保持 Private。aws ecr get-login-password 印出來的是 ECR 的登入密碼,只用 | 交給 docker login,不要單獨執行,更不要印在 logs 裡。

參考資料


上一篇
Day24 - OIDC:讓 GitHub Actions 向 AWS 換一把短期鑰匙
下一篇
Day26 - 網站上線之後,EC2 還有哪些事要顧?
系列文
前端來點 AWS 技能樹! 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言